<?php

/**
 * QPHP
 *
 * Copyright (c) 2022 QPHP
 *
 * @author lbnnbs
 * @copyright Copyright (c) 2022 lbnnbs QPHP (https://gitee.com/lbnnbs/qphp)
 * @license AGPLv3 License
 *
 * 后台进程
 *
 * 仅适用于Linux系统，必须以root用户启动
 *
 * 必须在CLI下启动，php xxx.php start
 *
 * start 启动，stop 停止， restart 重启，status 查看状态
 *
 * -d 运作在daemon模式
 *
 * php xxx.php start -d 或者 php xxx.php restart -d
 */
class Daemon {
    
    const DLOG_TO_CONSOLE = 1;
    const DLOG_NOTICE = 2;
    const DLOG_WARNING = 4;
    const DLOG_ERROR = 8;
    const DLOG_CRITICAL = 16;
    
    /**
     * User ID
     * 设定进程以什么用户运行（用户的uid），一般可以设定为php-fpm的运行账户
     *
     * @var int
     */
    private $userID = 65534; // nobody
    
    /**
     * Group ID
     * 设定进程以什么用户运行（用户的uid），一般可以设定为php-fpm的运行账户
     *
     * @var int
     */
    private $groupID = 65533; // nobody
    
    /**
     * Daemon flag
     *
     * @var string
     */
    private $flag = 'daemon';
    
    /**
     * 进程信息记录目录
     *
     * @var string
     */
    private $processLocation = '/tmp/daemon';
    
    /**
     * 日志文件
     *
     * @var string
     */
    private $logFileLocation = '/tmp/daemon/daemon.log';
    
    /**
     * 进程PID文件
     *
     * @var string
     */
    private $pidFileLocation = '/tmp/daemon/daemon.pid';
    
    /**
     * 停止运行标志文件
     *
     * @var string
     */
    private $stopFileLocation = '/tmp/daemon/daemon.st';
    
    /**
     * 进程心跳包文件
     *
     * @var string
     */
    private $processHeartLocation = '';
    
    /**
     * 进程命令参数文件
     *
     * @var string
     */
    private $processCmdFile = '';
    
    /**
     * 进程路径信息文件
     *
     * @var string
     */
    private $processPwdFile = '';
    
    /**
     * 当前进程ID
     *
     * @var int
     */
    private $_pid = 0;
    
    /**
     * 是否是子进程
     *
     * @var bool
     */
    private $_isChildren = false;
    
    /**
     * 是否运行在daemon模式
     *
     * @var bool
     */
    private $_isDaemon = false;
    
    /**
     * Task
     *
     * @var array
     */
    private $_task = array();
    
    /**
     *
     * @param string $flag 任务标识
     * @param int|string $uid 指定运行该任务的用户ID或用户名，一般可以设定为php-fpm的运行账户，默认root
     * @param int|string $gid 指定运行该任务的用户组ID或用户组名，一般可以设定为php-fpm的运行账户，默认root
     * @param string $dir 任务相关文件存储路径，默认/tmp
     */
    public function __construct($flag = 'daemon', $uid = 0, $gid = 0, $dir = '/tmp') {
        
        if (!is_numeric($uid)) {
            $user_info = \posix_getpwnam($uid);
            if (!$user_info) {
                $this->_logMessage("Warning: User {$uid} not exsits", self::DLOG_TO_CONSOLE);
                die();
            }
            $uid = $user_info['uid'];
        }
        
        if (!is_numeric($gid)) {
            $group_info = \posix_getgrnam($gid);
            if (!$group_info) {
                $this->_logMessage("Warning: Group {$gid} not exsits", self::DLOG_TO_CONSOLE);
                die();
            }
            $gid = $group_info['gid'];
        }
        
        $this->flag = $flag;
        $this->userID = $uid;
        $this->groupID = $gid;
        $this->processLocation = "{$dir}/{$flag}";
        $this->logFileLocation = "{$this->processLocation}/error.log";
        $this->pidFileLocation = "{$this->processLocation}/pid";
        $this->stopFileLocation = "{$this->processLocation}/st";
        
        register_shutdown_function(array(&$this, 'releaseDaemon'));
    }
    
    /**
     * 设置任务
     *
     * @param int $interval 执行间隔
     * @param callable $callable 可被调用的函数
     */
    public function setTask($interval = 1, $callable = null) {
        
        if ($interval < 1) {
            $this->_logMessage("Bad interval，min 1 second", self::DLOG_TO_CONSOLE);
            return;
        }
        
        if (!is_callable($callable)) {
            $this->_logMessage("Not callable", self::DLOG_TO_CONSOLE);
            return;
        }
        
        $this->_task = array('callable' => $callable, 'interval' => $interval, 'run_time' => time() + $interval);
    }
    
    public function run() {
        
        $argv = $_SERVER['argv'];
        
        if (!isset($argv[1])) {
            $this->_logMessage("Usable arguments are start/stop/restart/status, -d for daemon mode.", self::DLOG_TO_CONSOLE);
            return;
        }
        
        if ($argv[2] == '-d') {
            $this->_isDaemon = true;
        }
        
        if ($argv[1] === 'start') {
            $this->_start();
        }
        
        if ($argv[1] === 'stop') {
            $this->_stop();
        }
        
        if ($argv[1] === 'restart') {
            $this->_stop();
            $this->_start();
        }
        
        if ($argv[1] === 'status') {
            $this->_readHeartbeat();
        }
        
    }
    
    /**
     * 启动进程
     */
    private function _start() {
        
        if (!(PHP_SAPI === 'cli')) {
            $this->_logMessage("Cound not running daemon in not cli environment", self::DLOG_TO_CONSOLE);
            return;
        }
        
        $this->_logMessage("Starting daemon {$this->flag} ...", self::DLOG_TO_CONSOLE);
        
        if (!$this->_daemonize()) {
            // 启动后台进程失败，退出
            return;
        }
        
        /* 这后面的代码都是子进程执行的，父进程在启动后台进程成功后已经退出 */
        
        while (!$this->_isStopping()) { // 保障后台程序一直执行
            
            pcntl_signal_dispatch(); // 分发信号，下面的sleep可以保障每隔1秒分发一次
            
            // pcntl_signal_dispatch()这个函数是PHP5.3以上才支持的，如果你的PHP版本大于5.3，建议使用这个方法调用信号处理器。5.3以下的版本需要在注册信号之前加上：declare(ticks = 1);
            
            sleep(1); // 信号会打断系统调用，sleep也是系统调用，会被信号打断，所以有可能不会有1秒，这个1秒也不会影响信号
            
        }
    }
    
    /**
     * 停止进程
     */
    private function _stop() {
        
        if (!(PHP_SAPI === 'cli')) {
            $this->_logMessage("Cound not running daemon in not cli environment", self::DLOG_TO_CONSOLE);
            return;
        }
        
        if (!$fp = fopen($this->stopFileLocation, 'w')) {
            $this->_logMessage("Could not write to stop file, Check problem and kill process with kill -9 pid", self::DLOG_TO_CONSOLE);
        } else {
            $this->_logMessage("Stopping daemon {$this->flag} ...", self::DLOG_TO_CONSOLE);
            
            if (is_file($this->pidFileLocation)) {
                
                $pid = file_get_contents($this->pidFileLocation);
                
                // 写入stop文件，以便在_tick()中判断是否要停止并设置_isRunning为false
                fputs($fp, $pid);
                fclose($fp);
                
                \posix_kill($pid, \SIGTERM); // 发送终止信号，该信号会打断_start()函数死循环里面的sleep，以便快速结束进程，不需要等待sleep。
                
                $i = 0;
                
                /*
                 * stop调用后，_isStopping() 被 _tick()设置为true
                 * 然后start()里的死循环跳出，程序结束
                 * releaseDaemon()被调用，删除pid文件
                 */
                while (is_file($this->pidFileLocation) && $i < 2) {
                    $i++;
                    clearstatcache();
                    sleep(1);
                }
                
                if (is_file($this->pidFileLocation)) {
                    $this->_logMessage("Could not stop daemon {$this->flag}, Check problem and kill process with kill -9 {$pid}", self::DLOG_TO_CONSOLE);
                    return;
                }
            } else {
                
                fclose($fp);
            }
            
            /*
             * pid文件被删除，说明daemon已停止，清理残留的stop文件
             */
            unlink($this->stopFileLocation);
            
            $this->_logMessage("Daemon {$this->flag} stopped", self::DLOG_TO_CONSOLE);
        }
    }
    
    /**
     * 把标准输出和标准错误重定向到 /dev/null
     *
     * 如果不重定向输出，输出就会被发送到控制终端，这对于后台进程来说是没有必要的
     * 而且如果子进程有输出，子进程又会被强制挂载到会话的主进程也就是控制终端进程中
     * 这就会导致一旦子进程有输出，就不是daemon进程了，会随会话关闭而结束
     *
     * 这里不但关闭了标准输出和标准错误，还在关闭后重定向到了 /dev/null
     * 是为了防止有些程序依赖输出（比如某些程序会判断输出是否成功，输出失败就退出），直接关闭会导致不可预知的错误发生
     */
    private function _resetStd() {
        
        global $STDOUT, $STDERR;
        
        $handle = \fopen('/dev/null', "a");
        
        if ($handle) {
            unset($handle);
            \set_error_handler(function () {
            });
            
            if ($STDOUT) {
                \fclose($STDOUT);
            }
            if ($STDERR) {
                \fclose($STDERR);
            }
            
            \fclose(\STDOUT);
            \fclose(\STDERR);
            $STDOUT = \fopen('/dev/null', "a"); // 重定向标准输出到 /dev/null
            $STDERR = \fopen('/dev/null', "a"); // 重定向标准错误到 /dev/null
            \restore_error_handler();
            
            return true;
        }
        
        $this->_logMessage('Can not open /dev/null');
        
        return false;
    }
    
    private function _daemonize() {
        
        if (ob_get_level() > 0) {
            ob_end_flush();
        }
        
        // 切换到根目录，以便通过umask设定当前进程创建的文件和目录的默认权限
        chdir('/');
        umask(0); // 设定目录777权限，文件666权限
        
        if (!file_exists($this->processLocation)) {
            @mkdir($this->processLocation, 0777, true);
        }
        
        if ($this->_isDaemonRunning()) {
            // 后台任务已经在执行了，不需要重复启动
            return false;
        }
        
        if ($this->_isDaemon) {
            
            if (!$this->_fork()) { // 把当前进程分叉为父进程和子进程，并结束父进程
                // 无法分叉进程，退出
                return false;
            }
            
        }
        
        /* 这里开始，后面的代码都是由子进程执行了 */
        
        if (!$this->_setIdentity()) {
            // 设置进程（daemon模式下是子进程）的执行用户和用户组
            return false;
        }
        
        if ($this->_isDaemon) {
            /*
             * 设置当前进程为会话领头进程
             *
             * 用户登录后都会开启一个会话
             * 所有该用户的进程默认都在这个会话里面，结束会话，会话内的所有进程也都会结束
             * 一个会话一般会开启一个控制终端用来执行IO操作，比如键盘输入，显示器输出
             * 如果使用控制终端使用Ctrl+C或使用其他方式发送了INT中断信号终止会话，就会导致会话下的所有进程退出
             *
             * 会话内的所有进程中有一个领头进程，默认的会话领头进程应该用户登录后的shell/bash，会话领头进程的SID和PID是一样的
             * 这个shell/bash正常就是控制终端，所以会话领头进程会负责所有进程的显示输出。
             *
             * 所以这里调用posix_setsid，把当前子进程的设置为会话领头进程，这样就会脱离原有的会话
             * 达到会话退出和父进程退出都不会导致子进程被结束的问题
             *
             * posix_setsid后，当前子进程的托管会从当前会话主进程脱离，转为由系统的init或systemd托管
             */
            if (posix_setsid() === -1) {
                $this->_logMessage('Could not make the current process a session leader', self::DLOG_ERROR);
                
                return false;
            }
        } else {
            $this->_pid = posix_getpid();
        }
        
        if (!$fp = fopen($this->pidFileLocation, 'w')) {
            $this->_logMessage('Could not write to PID file ' . $this->pidFileLocation, self::DLOG_ERROR);
            return false;
        } else {
            fputs($fp, $this->_pid);
            fclose($fp);
        }
        
        // 写入监控日志
        $this->writeProcess();
        
        /*
         * ticks=1 每执行一条低级语句触发一次信号处理。
         * 如果语句多的话，每个语句都要执行一次 sigHandler，就会造成很多CPU消耗。
         * 较好的做法是使用 pcntl_signal_dispatch，每隔一定时间做一次信号分发，能调用到sigHandler即可。
         */
        // declare(ticks=1);
        
        pcntl_signal(SIGCHLD, array(&$this, 'sigHandler'));
        pcntl_signal(SIGTERM, array(&$this, 'sigHandler'));
        pcntl_signal(SIGALRM, array(&$this, 'sigHandler')); // 注册时钟信号处理程序
        
        pcntl_alarm(1); // 1秒间隔发送时钟信号，这样即使没有低级语句执行，也能触发一次信号
        
        $this->_logMessage("Daemon {$this->flag} running with PID: {$this->_pid}", self::DLOG_TO_CONSOLE | self::DLOG_NOTICE);
        
        if ($this->_isDaemon) {
            if (!$this->_resetStd()) { // 把标准输出和标准错误重定向到 /dev/null
                // 重定向失败，退出
                return false;
            }
        }
        
        return true;
    }
    
    /**
     * Signals handler
     *
     */
    public function sigHandler($sigNo) {
        
        switch ($sigNo) {
            
            case SIGALRM:   // 时钟信号
                
                $this->_tick();
                break;
            
            case SIGTERM:   // Shutdown
                $this->_logMessage('Shutdown signal');
                exit();
            
            case SIGCHLD:   // Halt
                $this->_logMessage('Halt signal');
                while (pcntl_waitpid(-1, $status, WNOHANG) > 0) ;
                break;
        }
    }
    
    private function _tick() {
        
        if ($this->_isStopping()) {
            return;
        }
        
        if (!$this->_writeHeart()) {
            $this->_logMessage("Can not write heart", self::DLOG_ERROR);
        }
        
        pcntl_alarm(1); // 1秒间隔发送时钟信号
        
        if (time() >= $this->_task['run_time']) {
            call_user_func($this->_task['callable']); // 执行任务
            $this->_task['run_time'] = time() + $this->_task['interval'];
        }
    }
    
    private function _isStopping() {
        clearstatcache();
        if (is_file($this->stopFileLocation)) {
            return true;
        } else {
            return false;
        }
    }
    
    /**
     * 释放 daemon pid 文件，daemon结束时执行
     *
     * @return void
     */
    public function releaseDaemon() {
        if ($this->_isChildren && is_file($this->pidFileLocation)) {
            $error = error_get_last();
            if ($error) {
                $err_msg = $error['message'] . "\n" . $error['file'] . ": " . $error['line'];
                $this->_logMessage("Releasing daemon {$this->flag} with error: " . $err_msg, self::DLOG_ERROR);
            } else {
                $this->_logMessage("Releasing daemon {$this->flag}");
            }
            
            $pid = file_get_contents($this->pidFileLocation);
            
            unlink($this->processHeartLocation);
            unlink($this->processCmdFile);
            unlink($this->processPwdFile);
            rmdir($this->processLocation . '/' . $pid);
            unlink($this->pidFileLocation);
        }
    }
    
    /**
     * 检查daemon是否在运行
     *
     * @return bool
     */
    private function _isDaemonRunning() {
        if (is_file($this->pidFileLocation)) {
            $oldPid = file_get_contents($this->pidFileLocation);
            
            /*
             * posix_kill可以用来给进程发送信号
             * 如果信号值为0，则不发送信号，但仍执行错误检查
             * 这可用于检查进程ID或进程组ID的存在
             */
            if ($oldPid !== false && posix_kill(trim($oldPid), 0)) {
                
                $this->_logMessage("Daemon {$this->flag} is running with PID: {$oldPid}", (self::DLOG_TO_CONSOLE | self::DLOG_ERROR));
                
                return true;
            } else {
                return false;
            }
        } else {
            return false;
        }
    }
    
    /**
     * 检查心跳
     */
    private function _readHeartbeat() {
        
        if (!is_file($this->pidFileLocation)) {
            $this->_logMessage("Daemon {$this->flag} has stopped, pid {$this->pidFileLocation} file not found!", self::DLOG_TO_CONSOLE);
            return;
        }
        
        $oldPid = file_get_contents($this->pidFileLocation);
        
        if (!$oldPid) {
            $this->_logMessage("Daemon {$this->flag} has stopped, pid file {$this->pidFileLocation} is empty!", self::DLOG_TO_CONSOLE);
            return;
        }
        
        $this->processHeartLocation = $this->processLocation . '/' . $oldPid . '/heart';
        
        if (!is_file($this->processHeartLocation)) {
            $this->_logMessage("Daemon {$this->flag} has stopped, heart file {$this->processHeartLocation} not found!", self::DLOG_TO_CONSOLE);
            return;
        }
        
        $last_time = intval(file_get_contents($this->processHeartLocation));
        
        $this->_logMessage("Daemon {$this->flag} is running with PID: {$oldPid}, heartbeat at {$last_time}", self::DLOG_TO_CONSOLE);
        
        sleep(1);
        
        $this->_readHeartbeat();
        
    }
    
    /**
     * Forks process
     *
     * @return bool
     */
    private function _fork() {
        
        $this->_logMessage('Forking...');
        
        /*
         * pcntl_fork函数的内部有个分叉操作，会把当前进程分叉为父进程和子进程2个并行进程
         *
         * 分叉后，父进程继续执行pcntl_fork函数内部分叉点后的其他代码，最后返回父进程ID
         * 子进程也继续执行pcntl_fork函数内部分叉点后的其他代码，但返回父进程ID为0，用来标识这个是个子进程
         *
         * 子进程会执行父进程完全一样的代码，所以需要使用返回的父进程id来作为分支条件，让父进程退出，让子进程执行真正的任务
         *
         * 多次调用就会分叉多个子进程
         */
        $pid = pcntl_fork(); // 返回父进程ID
        
        if ($pid === -1) {
            // 出错
            $this->_logMessage('Could not fork', self::DLOG_ERROR);
            
            return false;
        } elseif ($pid) {
            // 父进程
            $this->_logMessage('Killing parent');
            
            exit(); // 父进程退出
        } else {
            // fork的子进程
            $this->_isChildren = true;
            
            // 获取子进程的ID，如果分叉了多个子进程，就可以使用这个子进程ID来识别和处理并发
            $this->_pid = posix_getpid();
            
            $this->_logMessage('Fork success, children\'s pid ' . $this->_pid);
            
            return true;
        }
    }
    
    /**
     * 设置子进程执行的用户和用户组
     *
     * @return bool
     */
    private function _setIdentity() {
        
        if (!posix_setgid($this->groupID) || !posix_setuid($this->userID)) {
            $this->_logMessage("Could not set identity, uid: {$this->userID}, gid: {$this->groupID}", self::DLOG_WARNING);
            
            return false;
        } else {
            return true;
        }
    }
    
    /**
     * 将当前进程信息写入文件
     *
     * @return void
     */
    public function writeProcess() {
        
        $this->processHeartLocation = $this->processLocation . '/' . $this->_pid . '/heart';
        
        $command = trim(implode(' ', $_SERVER['argv']));
        
        /*
         * 指定进程的目录
         *
         * 这里使用pid创建子目录，如果重复启动了子进程就可以很方便的进行识别
         * 也有利于在有多个子进程时进行区分
         */
        $processDir = $this->processLocation . '/' . $this->_pid;
        $this->processCmdFile = $processDir . '/cmd';
        $this->processPwdFile = $processDir . '/pwd';
        
        // 所有进程所在的目录
        if (!is_dir($this->processLocation)) {
            @mkdir($this->processLocation, 0777, true);
            @chmod($processDir, 0777);
        }
        
        // 查询重复的进程记录
        $pDirObject = dir($this->processLocation);
        while ($pDirObject && (($pid = $pDirObject->read()) !== false)) {
            if ($pid === '.' || $pid === '..' || intval($pid) !== $pid) {
                continue;
            }
            
            $pDir = $this->processLocation . '/' . $pid;
            $pCmdFile = $pDir . '/cmd';
            $pPwdFile = $pDir . '/pwd';
            $pHeartFile = $pDir . '/heart';
            
            // 根据cmd检查启动相同参数的进程
            if (is_file($pCmdFile) && trim(file_get_contents($pCmdFile)) === $command) {
                is_file($pCmdFile) && unlink($pCmdFile);
                is_file($pPwdFile) && unlink($pPwdFile);
                is_file($pHeartFile) && unlink($pHeartFile);
                
                // 删目录有缓存
                usleep(1000);
                
                rmdir($pDir);
            }
        }
        
        // 新进程目录
        if (!is_dir($processDir)) {
            @mkdir($processDir, 0777);
            @chmod($processDir, 0777);
        }
        
        // 写入命令参数
        file_put_contents($this->processCmdFile, $command);
        file_put_contents($this->processPwdFile, $_SERVER['PWD']);
        
        // 写文件有缓存
        usleep(1000);
    }
    
    /**
     * 写入心跳
     *
     */
    private function _writeHeart() {
        
        return file_put_contents($this->processHeartLocation, time());
    }
    
    /**
     * 记录日志
     *
     * @param string $msg 消息
     * @param int $level 级别
     * @return void
     */
    private function _logMessage($msg, $level = self::DLOG_NOTICE) {
        if ($level & self::DLOG_TO_CONSOLE) {
            echo $msg . PHP_EOL;
            
            if ($level === self::DLOG_TO_CONSOLE) {
                return;
            }
        }
        
        if ($fp = fopen($this->logFileLocation, 'a')) {
            fputs($fp, date('Y-m-d H:i:s') . ': ' . $msg . PHP_EOL);
            fclose($fp);
        }
    }
    
}
